A Brief Discussion on C# Property Syntactic Sugar and the Evolution of the NRT Mechanism
The reason I am writing this is that I recently stumbled upon the field keyword in C# 14. It allows auto-properties to include custom logic, so you no longer have to revert to a manual backing field just for a single Trim() call. I decided to take this opportunity to organize and review several other syntax features that improve the NRT (Nullable Reference Types) experience.
The Evolution of C# Property Syntax
C# properties incorporate the early "Getter/Setter design pattern" directly into the language, using methods to wrap fields. Although they look like fields from the caller's perspective, the definition side used to be quite verbose, leading to the introduction of various syntactic sugars to simplify definitions.
C# versions are typically released alongside the .NET SDK. The following notes indicate the version in which each syntax first appeared.
1. Early Classical Approach: Backing Field (C# 1.0 / .NET Framework 1.0)
public class User {
private string name;
public string Name {
get { return name; }
set { name = value; }
}
}2. Auto-Implemented Properties (C# 3.0 / .NET Framework 3.5)
In practice, most properties are simply data containers without any logic. Writing private string name; every time is very tedious, so auto-properties were introduced, with the compiler generating the underlying fields automatically.
After auto-properties were introduced, many beginners during the Web Forms era struggled to distinguish between properties and fields (though when I first learned C#, I was mostly stuck on the semantics and usage timing of properties versus Get methods).
public class User {
public string Name { get; set; }
}3. Property Initializers (C# 6.0 / .NET Framework 4.6)
When using C# 3.0 auto-properties, if you needed to provide an initial value, you had to abandon the auto-property and revert to the C# 1.0 backing field approach. To solve this, C# 6.0 allowed assigning initial values directly to auto-properties.
public class User {
public string Name { get; set; } = "Default Name";
}4. Expression-bodied Properties: Lambda Syntactic Sugar (C# 6.0, C# 7.0 / .NET Framework 4.6, 4.7)
To solve the issue of deeply nested curly braces { }, C# 6.0 introduced the => syntax for read-only properties, and C# 7.0 extended this to both get and set, making the code flatter.
public class User {
private string name;
public string Role => "User";
public string Name {
get => name;
set => name = value.Trim();
}
}WARNING
Property initializers and read-only expression-bodied syntax look very similar, and it is easy for those unfamiliar with them to make mistakes. However, they differ completely in memory usage and execution lifecycle, which can easily lead to bugs in practice.
public string Name => "Default Name"(Expression-bodied): Dynamically calculated. The code is re-executed every time it is called.public string Name { get; } = "Default Name"(Property Initializer): Statically cached. It is executed only once when the object is instantiated (new), and the value remains fixed thereafter.
Disaster Scenario Example (using Guid):
public class Order {
// ❌ Incorrect: A new Guid is generated every time OrderId is read, which causes issues with serialization or log tracking.
public Guid OrderId => Guid.NewGuid();
// ✅ Correct: Generated only once during new(), then the state remains unchanged.
public Guid CorrectOrderId { get; } = Guid.NewGuid();
}
// Caller
Order order = new();5. Semi-Auto Properties and the field Keyword (C# 13 Preview, C# 14 / .NET 9, .NET 10)
Despite the syntactic sugar mentioned above, if you want to add a tiny bit of logic to a set (such as Trim() or NotifyPropertyChanged), the auto-property breaks immediately. Especially in DDD, where you often add validation logic or data correction to a set, you have to revert to the C# 1.0 manual private field declaration.
To completely eliminate meaningless field declarations, Microsoft finally introduced the new contextual keyword field, allowing you to directly access the field generated by the compiler.
public class User {
public string Name {
get;
set => field = value.Trim();
}
}TIP
It is recommended to perform logic processing in set whenever possible, as get is used more frequently and carries some hidden overhead. This also avoids potential issues where frameworks like Entity Framework Core might bypass get and access the backing field directly.
INFO
C# 12 introduced Primary Constructors to reduce field declarations, but I personally have reservations about them and consider it a controversial design.
Beyond trying it out when it was first released, I rarely use it unless I'm being lazy. To me, it feels like a product that sacrifices semantic clarity for simplicity. It not only easily induces developers to ignore necessary parameter checks (Guard Clauses) for the sake of convenience, but it also blurs the positioning and boundaries between parameters, fields, and properties. If the class logic is slightly complex and not well-written, this implicit capturing mechanism makes the object's internal state messy and difficult to identify.
NRT (Nullable Reference Types) and Mechanism Completion
NRT was introduced in C# 8.0 with the goal of eliminating NullReferenceException. After all, people often say the biggest failure in programming is null. That said, C# structs have Nullable<T>, allowing structs (which are Value Types) to represent a null state; therefore, the core of the problem should be whether we can "explicitly identify" the possibility of its existence.
For details on NRT, refer to Nullable reference types. Its main purpose is to allow developers to use ? to annotate reference types, explicitly declaring "this might be null," thereby establishing a clear contract.
- ASP.NET Core's string binding validation will still block
nullvalues if[Required]is not added, but it won't block empty strings. So if you hear from someone else's API that they need to pass an empty string if there is no value, this is basically why. - Since Entity Framework Core 5.0, for Code First property definitions, if
?is not marked, EF Core expects the database column to beNOT NULL.
If you want to make violations of these constraints cause compilation failures, you need to add <WarningsAsErrors>nullable</WarningsAsErrors>. This setting specifies which warnings become compilation errors. You can also add other error codes (such as async-related CS4014;CS1998) separated by semicolons to force them to be non-compilable.
However, I didn't like enabling NRT previously. For one, the greatest significance of this setting lies in interface contracts; if not everyone on the team is willing to follow it, it just becomes misleading noise. The maintenance cost is high, so I wouldn't want to force others to comply. In that case, it's better to turn it off rather than seeing the compiler throw a bunch of annoying warnings. Secondly, at the time, this mechanism had a terrible experience for DTOs (Data Transfer Objects).
Why did I want to turn it off before?
During the C# 8 to C# 10 era, NRT required non-null properties to be initialized. But DTOs are usually assigned values by deserialization tools or ORMs. To avoid compiler warnings, one had to write meaningless default values or use ! (null-forgiving) to tell the compiler that a value definitely exists:
public class UserDto {
// Meaningless code written to trick the compiler
public string UserName { get; set; } = "";
public string NickName { get; set; } = null!;
public string Email { get; set; } = default!;
}Why did I dislike this? Setting = "" is generally used when the default is an empty string, but under NRT, it became a means to suppress warnings. And null!, telling the compiler that a value exists here, won't actually guarantee a value if the set isn't triggered, leading to misjudgment. This forces class definitions to bear promises they cannot guarantee, potentially leading to even harder-to-track issues.
Mechanism Completion
Later, I saw the introduction of the following mechanisms, and I feel that the NRT mechanism is now complete (definitely not because I can ask AI to help handle it).
init(C# 9.0): Allows properties to be assigned during object initialization (new() { ... }) and then become read-only, protecting data immutability.required(C# 11.0): It forces the caller to "must" provide a value for the property at the moment ofnew(). With this guarantee, the compiler no longer forces you to writenull!inside the class.
public class UserDto {
public required string UserName { get; init; }
}
// The caller must provide a value, otherwise compilation fails
UserDto dto = new() { UserName = "Alice" };[SetsRequiredMembers]: Resolving Conflicts with Constructors
required must be used in conjunction with [SetsRequiredMembers] because it forces the caller to use curly braces { } to provide values. If you provide a traditional parameterized constructor, the compiler will still issue a warning because it only recognizes { }.
In this case, you need to add the [SetsRequiredMembers] attribute to tell the compiler that all required properties have been set within this constructor.
using System.Diagnostics.CodeAnalysis;
public class User {
public required string UserName { get; init; }
public required string Email { get; init; }
// By adding this Attribute, the compiler will no longer require external callers to use { } for initialization
[SetsRequiredMembers]
public User(string userName, string email) {
UserName = userName;
Email = email;
}
}
// Caller
User user = new("Alice", "[email protected]");However, in practice, handling NRT also requires using [AllowNull] and [NotNull]. I won't mention them here (because I've forgotten many of them and don't want to organize them in this article for now). For details, refer to Attributes for null-state static analysis.
Application of required in Web API
Beyond improving the NRT experience, when building APIs in the past, we often encountered situations where a struct was clearly required, but to distinguish whether the frontend passed a default value (like 0 or false) or missed the property entirely, we had to use Nullable<T> (this refers to [FromBody]; for the [FromForm] submission route, there was [BindRequired] available, but now in most cases, you can consider using required).
Of course, this part is fine for me, but there are indeed people online who feel this is a compromise that breaks semantic boundaries. However, now with required, if a property is marked as required but is missed by the frontend, a JsonException will be thrown. For details, refer to Required properties.
Afterword
With property syntactic sugar evolving to this extent, apart from Lazy<T>, I can't think of anything else that forces the addition of field handling. This is why I wanted to write a note when I saw this. I look forward to the day Microsoft adds syntactic sugar for Lazy<T> as well.
Now that the NRT mechanism is complete, I am quite willing to use it in personal projects. It's just that with AI Agents, I've become lazier and often ask them to handle it for me. Sometimes I encounter situations where I feel the AI Agent knows better how to handle it than I do, but sometimes, whether due to a lack of context or laziness, it takes several tries to get it right. I feel like I should find time to create a Skill to handle this.
Change Log
- 2026-03-30 Initial document created.